// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
import 'dart:io' as io;

import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/common/output_utils.dart';
import 'package:flutter_plugin_tools/src/common/package_looping_command.dart';
import 'package:git/git.dart';
import 'package:test/test.dart';

import '../mocks.dart';
import '../util.dart';

// Constants for colorized output start and end.
const String _startElapsedTimeColor = '\x1B[90m';
const String _startErrorColor = '\x1B[31m';
const String _startHeadingColor = '\x1B[36m';
const String _startSkipColor = '\x1B[90m';
const String _startSkipWithWarningColor = '\x1B[93m';
const String _startSuccessColor = '\x1B[32m';
const String _startWarningColor = '\x1B[33m';
const String _endColor = '\x1B[0m';

// The filename within a package containing warnings to log during runForPackage.
enum _ResultFileType {
  /// A file containing errors to return.
  errors,

  /// A file containing warnings that should be logged.
  warns,

  /// A file indicating that the package should be skipped, and why.
  skips,

  /// A file indicating that the package should throw.
  throws,
}

// The filename within a package containing errors to return from runForPackage.
const String _errorFile = 'errors';
// The filename within a package indicating that it should be skipped.
const String _skipFile = 'skip';
// The filename within a package containing warnings to log during runForPackage.
const String _warningFile = 'warnings';
// The filename within a package indicating that it should throw.
const String _throwFile = 'throw';

/// Writes a file to [package] to control the behavior of
/// [TestPackageLoopingCommand] for that package.
void _addResultFile(
  RepositoryPackage package,
  _ResultFileType type, {
  String? contents,
}) {
  final File file = package.directory.childFile(_filenameForType(type));
  file.createSync();
  if (contents != null) {
    file.writeAsStringSync(contents);
  }
}

String _filenameForType(_ResultFileType type) {
  switch (type) {
    case _ResultFileType.errors:
      return _errorFile;
    case _ResultFileType.warns:
      return _warningFile;
    case _ResultFileType.skips:
      return _skipFile;
    case _ResultFileType.throws:
      return _throwFile;
  }
}

void main() {
  late MockPlatform mockPlatform;
  late Directory packagesDir;
  late Directory thirdPartyPackagesDir;
  late GitDir gitDir;
  late RecordingProcessRunner gitProcessRunner;

  setUp(() {
    mockPlatform = MockPlatform();
    (:packagesDir, processRunner: _, :gitProcessRunner, :gitDir) =
        configureBaseCommandMocks(platform: mockPlatform);
    // Correct color handling is part of the behavior being tested here.
    useColorForOutput = true;
    thirdPartyPackagesDir = packagesDir.parent
        .childDirectory('third_party')
        .childDirectory('packages');
  });

  tearDown(() {
    // Restore the default behavior.
    useColorForOutput = io.stdout.supportsAnsiEscapes;
  });

  /// Creates a TestPackageLoopingCommand with the given configuration.
  TestPackageLoopingCommand createTestCommand({
    bool hasLongOutput = true,
    PackageLoopingType packageLoopingType = PackageLoopingType.topLevelOnly,
    bool failsDuringInit = false,
    bool warnsDuringInit = false,
    bool captureOutput = false,
    String? customFailureListHeader,
    String? customFailureListFooter,
  }) {
    return TestPackageLoopingCommand(
      packagesDir,
      platform: mockPlatform,
      hasLongOutput: hasLongOutput,
      packageLoopingType: packageLoopingType,
      failsDuringInit: failsDuringInit,
      warnsDuringInit: warnsDuringInit,
      customFailureListHeader: customFailureListHeader,
      customFailureListFooter: customFailureListFooter,
      captureOutput: captureOutput,
      gitDir: gitDir,
    );
  }

  /// Runs [command] with the given [arguments], and returns its output.
  Future<List<String>> runCommand(
    TestPackageLoopingCommand command, {
    List<String> arguments = const <String>[],
    void Function(Error error)? errorHandler,
  }) async {
    late CommandRunner<void> runner;
    runner = CommandRunner<void>(
      'test_package_looping_command',
      'Test for base package looping functionality',
    );
    runner.addCommand(command);
    return runCapturingPrint(runner, <String>[
      command.name,
      ...arguments,
    ], errorHandler: errorHandler);
  }

  group('tool exit', () {
    test('is handled during initializeRun', () async {
      final TestPackageLoopingCommand command = createTestCommand(
        failsDuringInit: true,
      );

      expect(() => runCommand(command), throwsA(isA<ToolExit>()));
    });

    test('does not stop looping on error', () async {
      createFakePackage('package_a', packagesDir);
      final RepositoryPackage failingPackage = createFakePlugin(
        'package_b',
        packagesDir,
      );
      createFakePackage('package_c', packagesDir);
      _addResultFile(failingPackage, _ResultFileType.errors);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      Error? commandError;
      final List<String> output = await runCommand(
        command,
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startHeadingColor}Running for package_b...$_endColor',
          '${_startHeadingColor}Running for package_c...$_endColor',
        ]),
      );
    });

    test('does not stop looping on exceptions', () async {
      createFakePackage('package_a', packagesDir);
      final RepositoryPackage failingPackage = createFakePlugin(
        'package_b',
        packagesDir,
      );
      createFakePackage('package_c', packagesDir);
      _addResultFile(failingPackage, _ResultFileType.throws);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      Error? commandError;
      final List<String> output = await runCommand(
        command,
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startHeadingColor}Running for package_b...$_endColor',
          '${_startHeadingColor}Running for package_c...$_endColor',
        ]),
      );
    });
  });

  group('file filtering', () {
    test('runs command if the changed files list is empty', () async {
      createFakePackage('package_a', packagesDir);

      gitProcessRunner.mockProcessesForExecutable['git-diff'] =
          <FakeProcessInfo>[FakeProcessInfo(MockProcess(stdout: ''))];

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
        ]),
      );
    });

    test('runs command if any files are not ignored', () async {
      createFakePackage('package_a', packagesDir);

      gitProcessRunner.mockProcessesForExecutable['git-diff'] =
          <FakeProcessInfo>[
            FakeProcessInfo(
              MockProcess(
                stdout: '''
skip/a
other
skip/b
''',
              ),
            ),
          ];

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
        ]),
      );
    });

    test('skips commands if all files should be ignored', () async {
      createFakePackage('package_a', packagesDir);

      gitProcessRunner.mockProcessesForExecutable['git-diff'] =
          <FakeProcessInfo>[
            FakeProcessInfo(
              MockProcess(
                stdout: '''
skip/a
skip/b
''',
              ),
            ),
          ];

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        isNot(containsAllInOrder(<Matcher>[contains('Running for package_a')])),
      );
      expect(
        output,
        containsAllInOrder(<String>[
          '${_startSkipColor}SKIPPING ALL PACKAGES: No changed files affect this command$_endColor',
        ]),
      );
    });
  });

  group('package iteration', () {
    test('includes plugins and packages', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'a_plugin',
        packagesDir,
      );
      final RepositoryPackage package = createFakePackage(
        'a_package',
        packagesDir,
      );

      final TestPackageLoopingCommand command = createTestCommand();
      await runCommand(command);

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[plugin.path, package.path]),
      );
    });

    test('includes third_party/packages', () async {
      final RepositoryPackage package1 = createFakePackage(
        'a_package',
        packagesDir,
      );
      final RepositoryPackage package2 = createFakePackage(
        'another_package',
        thirdPartyPackagesDir,
      );

      final TestPackageLoopingCommand command = createTestCommand();
      await runCommand(command);

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[package1.path, package2.path]),
      );
    });

    test('includes all subpackages when requested', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'a_plugin',
        packagesDir,
        examples: <String>['example1', 'example2'],
      );
      final RepositoryPackage package = createFakePackage(
        'a_package',
        packagesDir,
      );
      final RepositoryPackage subPackage = createFakePackage(
        'sub_package',
        package.directory,
        examples: <String>[],
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeAllSubpackages,
      );
      await runCommand(command);

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[
          plugin.path,
          getExampleDir(plugin).childDirectory('example1').path,
          getExampleDir(plugin).childDirectory('example2').path,
          package.path,
          getExampleDir(package).path,
          subPackage.path,
        ]),
      );
    });

    test('includes examples when requested', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'a_plugin',
        packagesDir,
        examples: <String>['example1', 'example2'],
      );
      final RepositoryPackage package = createFakePackage(
        'a_package',
        packagesDir,
      );
      final RepositoryPackage subPackage = createFakePackage(
        'sub_package',
        package.directory,
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeExamples,
      );
      await runCommand(command);

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[
          plugin.path,
          getExampleDir(plugin).childDirectory('example1').path,
          getExampleDir(plugin).childDirectory('example2').path,
          package.path,
          getExampleDir(package).path,
        ]),
      );
      expect(command.checkedPackages, isNot(contains(subPackage.path)));
    });

    test('excludes subpackages when main package is excluded', () async {
      final RepositoryPackage excluded = createFakePlugin(
        'a_plugin',
        packagesDir,
        examples: <String>['example1', 'example2'],
      );
      final RepositoryPackage included = createFakePackage(
        'a_package',
        packagesDir,
      );
      final RepositoryPackage subpackage = createFakePackage(
        'sub_package',
        excluded.directory,
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeAllSubpackages,
      );
      await runCommand(command, arguments: <String>['--exclude=a_plugin']);

      final Iterable<RepositoryPackage> examples = excluded.getExamples();

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[included.path, getExampleDir(included).path]),
      );
      expect(command.checkedPackages, isNot(contains(excluded.path)));
      expect(examples.length, 2);
      for (final example in examples) {
        expect(command.checkedPackages, isNot(contains(example.path)));
      }
      expect(command.checkedPackages, isNot(contains(subpackage.path)));
    });

    test('excludes examples when main package is excluded', () async {
      final RepositoryPackage excluded = createFakePlugin(
        'a_plugin',
        packagesDir,
        examples: <String>['example1', 'example2'],
      );
      final RepositoryPackage included = createFakePackage(
        'a_package',
        packagesDir,
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeExamples,
      );
      await runCommand(command, arguments: <String>['--exclude=a_plugin']);

      final Iterable<RepositoryPackage> examples = excluded.getExamples();

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[included.path, getExampleDir(included).path]),
      );
      expect(command.checkedPackages, isNot(contains(excluded.path)));
      expect(examples.length, 2);
      for (final example in examples) {
        expect(command.checkedPackages, isNot(contains(example.path)));
      }
    });

    test('skips unsupported Flutter versions when requested', () async {
      final RepositoryPackage excluded = createFakePlugin(
        'a_plugin',
        packagesDir,
        flutterConstraint: '>=2.10.0',
      );
      final RepositoryPackage included = createFakePackage(
        'a_package',
        packagesDir,
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeAllSubpackages,
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--skip-if-not-supporting-flutter-version=2.5.0'],
      );

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[included.path, getExampleDir(included).path]),
      );
      expect(command.checkedPackages, isNot(contains(excluded.path)));

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for a_package...$_endColor',
          '${_startHeadingColor}Running for a_plugin...$_endColor',
          '$_startSkipColor  SKIPPING: Does not support Flutter 2.5.0$_endColor',
        ]),
      );
    });

    test('skips unsupported Dart versions when requested', () async {
      final RepositoryPackage excluded = createFakePackage(
        'excluded_package',
        packagesDir,
        dartConstraint: '>=2.18.0 <4.0.0',
      );
      final RepositoryPackage included = createFakePackage(
        'a_package',
        packagesDir,
      );

      final TestPackageLoopingCommand command = createTestCommand(
        packageLoopingType: PackageLoopingType.includeAllSubpackages,
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(
        command,
        arguments: <String>[
          '--skip-if-not-supporting-flutter-version=3.0.0', // Flutter 3.0.0 -> Dart 2.17.0
        ],
      );

      expect(
        command.checkedPackages,
        unorderedEquals(<String>[included.path, getExampleDir(included).path]),
      );
      expect(command.checkedPackages, isNot(contains(excluded.path)));

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for a_package...$_endColor',
          '${_startHeadingColor}Running for excluded_package...$_endColor',
          '$_startSkipColor  SKIPPING: Does not support Dart 2.17.0$_endColor',
        ]),
      );
    });
  });

  group('output', () {
    test('has the expected package headers for long-form output', () async {
      createFakePlugin('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand();
      final List<String> output = await runCommand(command);

      const separator =
          '============================================================';
      expect(
        output,
        containsAllInOrder(<String>[
          '$_startHeadingColor\n$separator\n|| Running for package_a\n$separator\n$_endColor',
          '$_startHeadingColor\n$separator\n|| Running for package_b\n$separator\n$_endColor',
        ]),
      );
    });

    test('has the expected package headers for short-form output', () async {
      createFakePlugin('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startHeadingColor}Running for package_b...$_endColor',
        ]),
      );
    });

    test('prints timing info in long-form output when requested', () async {
      createFakePlugin('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand();
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--log-timing'],
      );

      const separator =
          '============================================================';
      expect(
        output,
        containsAllInOrder(<String>[
          '$_startHeadingColor\n$separator\n|| Running for package_a [@0:00]\n$separator\n$_endColor',
          '$_startElapsedTimeColor\n[package_a completed in 0m 0s]$_endColor',
          '$_startHeadingColor\n$separator\n|| Running for package_b [@0:00]\n$separator\n$_endColor',
          '$_startElapsedTimeColor\n[package_b completed in 0m 0s]$_endColor',
        ]),
      );
    });

    test('prints timing info in short-form output when requested', () async {
      createFakePlugin('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--log-timing'],
      );

      expect(
        output,
        containsAllInOrder(<String>[
          '$_startHeadingColor[0:00] Running for package_a...$_endColor',
          '$_startHeadingColor[0:00] Running for package_b...$_endColor',
        ]),
      );
      // Short-form output should not include elapsed time.
      expect(output, isNot(contains('[package_a completed in 0m 0s]')));
    });

    test('shows the success message when nothing fails', () async {
      createFakePackage('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
    });

    test(
      'shows failure summaries when something fails without extra details',
      () async {
        createFakePackage('package_a', packagesDir);
        final RepositoryPackage failingPackage1 = createFakePlugin(
          'package_b',
          packagesDir,
        );
        createFakePackage('package_c', packagesDir);
        final RepositoryPackage failingPackage2 = createFakePlugin(
          'package_d',
          packagesDir,
        );
        _addResultFile(failingPackage1, _ResultFileType.errors);
        _addResultFile(failingPackage2, _ResultFileType.errors);

        final TestPackageLoopingCommand command = createTestCommand(
          hasLongOutput: false,
        );
        Error? commandError;
        final List<String> output = await runCommand(
          command,
          errorHandler: (Error e) {
            commandError = e;
          },
        );

        expect(commandError, isA<ToolExit>());
        expect(
          output,
          containsAllInOrder(<String>[
            '\n',
            '${_startErrorColor}The following packages had errors:$_endColor',
            '$_startErrorColor  package_b$_endColor',
            '$_startErrorColor  package_d$_endColor',
            '${_startErrorColor}See above for full details.$_endColor',
          ]),
        );
      },
    );

    test('uses custom summary header and footer if provided', () async {
      createFakePackage('package_a', packagesDir);
      final RepositoryPackage failingPackage1 = createFakePlugin(
        'package_b',
        packagesDir,
      );
      createFakePackage('package_c', packagesDir);
      final RepositoryPackage failingPackage2 = createFakePlugin(
        'package_d',
        packagesDir,
      );
      _addResultFile(failingPackage1, _ResultFileType.errors);
      _addResultFile(failingPackage2, _ResultFileType.errors);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
        customFailureListHeader: 'This is a custom header',
        customFailureListFooter: 'And a custom footer!',
      );
      Error? commandError;
      final List<String> output = await runCommand(
        command,
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<String>[
          '\n',
          '${_startErrorColor}This is a custom header$_endColor',
          '$_startErrorColor  package_b$_endColor',
          '$_startErrorColor  package_d$_endColor',
          '${_startErrorColor}And a custom footer!$_endColor',
        ]),
      );
    });

    test(
      'shows failure summaries when something fails with extra details',
      () async {
        createFakePackage('package_a', packagesDir);
        final RepositoryPackage failingPackage1 = createFakePlugin(
          'package_b',
          packagesDir,
        );
        createFakePackage('package_c', packagesDir);
        final RepositoryPackage failingPackage2 = createFakePlugin(
          'package_d',
          packagesDir,
        );
        _addResultFile(
          failingPackage1,
          _ResultFileType.errors,
          contents: 'just one detail',
        );
        _addResultFile(
          failingPackage2,
          _ResultFileType.errors,
          contents: 'first detail\nsecond detail',
        );

        final TestPackageLoopingCommand command = createTestCommand(
          hasLongOutput: false,
        );
        Error? commandError;
        final List<String> output = await runCommand(
          command,
          errorHandler: (Error e) {
            commandError = e;
          },
        );

        expect(commandError, isA<ToolExit>());
        expect(
          output,
          containsAllInOrder(<String>[
            '\n',
            '${_startErrorColor}The following packages had errors:$_endColor',
            '$_startErrorColor  package_b:\n    just one detail$_endColor',
            '$_startErrorColor  package_d:\n    first detail\n    second detail$_endColor',
            '${_startErrorColor}See above for full details.$_endColor',
          ]),
        );
      },
    );

    test('is captured, not printed, when requested', () async {
      createFakePlugin('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        captureOutput: true,
      );
      final List<String> output = await runCommand(command);

      expect(output, isEmpty);

      // None of the output should be colorized when captured.
      const separator =
          '============================================================';
      expect(
        command.capturedOutput,
        containsAllInOrder(<String>[
          '\n$separator\n|| Running for package_a\n$separator\n',
          '\n$separator\n|| Running for package_b\n$separator\n',
          'No issues found!',
        ]),
      );
    });

    test('logs skips', () async {
      createFakePackage('package_a', packagesDir);
      final RepositoryPackage skipPackage = createFakePackage(
        'package_b',
        packagesDir,
      );
      _addResultFile(
        skipPackage,
        _ResultFileType.skips,
        contents: 'For a reason',
      );

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startHeadingColor}Running for package_b...$_endColor',
          '$_startSkipColor  SKIPPING: For a reason$_endColor',
        ]),
      );
    });

    test('logs exclusions', () async {
      createFakePackage('package_a', packagesDir);
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--exclude=package_b'],
      );

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startSkipColor}Not running for package_b; excluded$_endColor',
        ]),
      );
    });

    test('logs warnings', () async {
      final RepositoryPackage warnPackage = createFakePackage(
        'package_a',
        packagesDir,
      );
      _addResultFile(
        warnPackage,
        _ResultFileType.warns,
        contents: 'Warning 1\nWarning 2',
      );
      createFakePackage('package_b', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startWarningColor}Warning 1$_endColor',
          '${_startWarningColor}Warning 2$_endColor',
          '${_startHeadingColor}Running for package_b...$_endColor',
        ]),
      );
    });

    test('logs unhandled exceptions as errors', () async {
      createFakePackage('package_a', packagesDir);
      final RepositoryPackage failingPackage = createFakePlugin(
        'package_b',
        packagesDir,
      );
      createFakePackage('package_c', packagesDir);
      _addResultFile(failingPackage, _ResultFileType.throws);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      Error? commandError;
      final List<String> output = await runCommand(
        command,
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<String>[
          '${_startErrorColor}Exception: Uh-oh$_endColor',
          '${_startErrorColor}The following packages had errors:$_endColor',
          '$_startErrorColor  package_b:\n    Unhandled exception$_endColor',
        ]),
      );
    });

    test('prints run summary on success', () async {
      final RepositoryPackage warnPackage1 = createFakePackage(
        'package_a',
        packagesDir,
      );
      _addResultFile(
        warnPackage1,
        _ResultFileType.warns,
        contents: 'Warning 1\nWarning 2',
      );

      createFakePackage('package_b', packagesDir);

      final RepositoryPackage skipPackage = createFakePackage(
        'package_c',
        packagesDir,
      );
      _addResultFile(
        skipPackage,
        _ResultFileType.skips,
        contents: 'For a reason',
      );

      final RepositoryPackage skipAndWarnPackage = createFakePackage(
        'package_d',
        packagesDir,
      );
      _addResultFile(
        skipAndWarnPackage,
        _ResultFileType.warns,
        contents: 'Warning',
      );
      _addResultFile(
        skipAndWarnPackage,
        _ResultFileType.skips,
        contents: 'See warning',
      );

      final RepositoryPackage warnPackage2 = createFakePackage(
        'package_e',
        packagesDir,
      );
      _addResultFile(
        warnPackage2,
        _ResultFileType.warns,
        contents: 'Warning 1\nWarning 2',
      );

      createFakePackage('package_f', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '------------------------------------------------------------',
          'Ran for 4 package(s) (2 with warnings)',
          'Skipped 2 package(s) (1 with warnings)',
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
      // The long-form summary should not be printed for short-form commands.
      expect(output, isNot(contains('Run summary:')));
      expect(output, isNot(contains(contains('package a - ran'))));
    });

    test('counts exclusions as skips in run summary', () async {
      createFakePackage('package_a', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
      );
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--exclude=package_a'],
      );

      expect(
        output,
        containsAllInOrder(<String>[
          '------------------------------------------------------------',
          'Skipped 1 package(s)',
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
    });

    test('prints long-form run summary for long-output commands', () async {
      final RepositoryPackage warnPackage1 = createFakePackage(
        'package_a',
        packagesDir,
      );
      _addResultFile(
        warnPackage1,
        _ResultFileType.warns,
        contents: 'Warning 1\nWarning 2',
      );

      createFakePackage('package_b', packagesDir);

      final RepositoryPackage skipPackage = createFakePackage(
        'package_c',
        packagesDir,
      );
      _addResultFile(
        skipPackage,
        _ResultFileType.skips,
        contents: 'For a reason',
      );

      final RepositoryPackage skipAndWarnPackage = createFakePackage(
        'package_d',
        packagesDir,
      );
      _addResultFile(
        skipAndWarnPackage,
        _ResultFileType.warns,
        contents: 'Warning',
      );
      _addResultFile(
        skipAndWarnPackage,
        _ResultFileType.skips,
        contents: 'See warning',
      );

      final RepositoryPackage warnPackage2 = createFakePackage(
        'package_e',
        packagesDir,
      );
      _addResultFile(
        warnPackage2,
        _ResultFileType.warns,
        contents: 'Warning 1\nWarning 2',
      );

      createFakePackage('package_f', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand();
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '------------------------------------------------------------',
          'Run overview:',
          '  package_a - ${_startWarningColor}ran (with warning)$_endColor',
          '  package_b - ${_startSuccessColor}ran$_endColor',
          '  package_c - ${_startSkipColor}skipped$_endColor',
          '  package_d - ${_startSkipWithWarningColor}skipped (with warning)$_endColor',
          '  package_e - ${_startWarningColor}ran (with warning)$_endColor',
          '  package_f - ${_startSuccessColor}ran$_endColor',
          '',
          'Ran for 4 package(s) (2 with warnings)',
          'Skipped 2 package(s) (1 with warnings)',
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
    });

    test('prints exclusions as skips in long-form run summary', () async {
      createFakePackage('package_a', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand();
      final List<String> output = await runCommand(
        command,
        arguments: <String>['--exclude=package_a'],
      );

      expect(
        output,
        containsAllInOrder(<String>[
          '  package_a - ${_startSkipColor}excluded$_endColor',
          '',
          'Skipped 1 package(s)',
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
    });

    test('handles warnings outside of runForPackage', () async {
      createFakePackage('package_a', packagesDir);

      final TestPackageLoopingCommand command = createTestCommand(
        hasLongOutput: false,
        warnsDuringInit: true,
      );
      final List<String> output = await runCommand(command);

      expect(
        output,
        containsAllInOrder(<String>[
          '${_startWarningColor}Warning during initializeRun$_endColor',
          '${_startHeadingColor}Running for package_a...$_endColor',
          '${_startWarningColor}Warning during completeRun$_endColor',
          '------------------------------------------------------------',
          'Ran for 1 package(s)',
          '2 warnings not associated with a package',
          '\n',
          '${_startSuccessColor}No issues found!$_endColor',
        ]),
      );
    });
  });
}

class TestPackageLoopingCommand extends PackageLoopingCommand {
  TestPackageLoopingCommand(
    super.packagesDir, {
    required super.platform,
    this.hasLongOutput = true,
    this.packageLoopingType = PackageLoopingType.topLevelOnly,
    this.customFailureListHeader,
    this.customFailureListFooter,
    this.failsDuringInit = false,
    this.warnsDuringInit = false,
    this.captureOutput = false,
    super.processRunner,
    super.gitDir,
  });

  final List<String> checkedPackages = <String>[];
  final List<String> capturedOutput = <String>[];

  final String? customFailureListHeader;
  final String? customFailureListFooter;

  final bool failsDuringInit;
  final bool warnsDuringInit;

  @override
  bool hasLongOutput;

  @override
  PackageLoopingType packageLoopingType;

  @override
  String get failureListHeader =>
      customFailureListHeader ?? super.failureListHeader;

  @override
  String get failureListFooter =>
      customFailureListFooter ?? super.failureListFooter;

  @override
  bool captureOutput;

  @override
  final String name = 'loop-test';

  @override
  final String description = 'sample package looping command';

  @override
  bool shouldIgnoreFile(String path) {
    return path.startsWith('skip/');
  }

  @override
  Future<void> initializeRun() async {
    if (warnsDuringInit) {
      logWarning('Warning during initializeRun');
    }
    if (failsDuringInit) {
      throw ToolExit(2);
    }
  }

  @override
  Future<PackageResult> runForPackage(RepositoryPackage package) async {
    checkedPackages.add(package.path);
    final File warningFile = package.directory.childFile(_warningFile);
    if (warningFile.existsSync()) {
      final List<String> warnings = warningFile.readAsLinesSync();
      warnings.forEach(logWarning);
    }
    final File skipFile = package.directory.childFile(_skipFile);
    if (skipFile.existsSync()) {
      return PackageResult.skip(skipFile.readAsStringSync());
    }
    final File errorFile = package.directory.childFile(_errorFile);
    if (errorFile.existsSync()) {
      return PackageResult.fail(errorFile.readAsLinesSync());
    }
    final File throwFile = package.directory.childFile(_throwFile);
    if (throwFile.existsSync()) {
      throw Exception('Uh-oh');
    }
    return PackageResult.success();
  }

  @override
  Future<void> completeRun() async {
    if (warnsDuringInit) {
      logWarning('Warning during completeRun');
    }
  }

  @override
  Future<void> handleCapturedOutput(List<String> output) async {
    capturedOutput.addAll(output);
  }
}
